feat: flat keyed payload for App Router layout persistence#750
feat: flat keyed payload for App Router layout persistence#750NathanDrake2406 wants to merge 39 commits intocloudflare:mainfrom
Conversation
- Fix stale closure on readBrowserRouterState by using a useRef updated synchronously during render instead of a closure captured in useLayoutEffect. External callers (navigate, server actions, HMR) now always read the current router state. - Restore GlobalErrorBoundary wrapping that was dropped when switching from buildPageElement to buildAppPageElements. Apps with app/global-error.tsx now get their global error boundary back. - Add exhaustive default case to routerReducer so new action types produce a compile error and a runtime throw instead of silent undefined. - Remove dead code: createRouteNodeSnapshot, AppRouteNodeSnapshot, AppRouteNodeValue were defined but never imported. - Remove deprecated buildAppPageRouteElement and its test — no production callers remain after the flat payload cutover. - Short-circuit normalizeAppElements when no slot keys need rewriting to avoid unnecessary allocation on every payload. - Align test data in error boundary RSC payload test (matchedParams slug: "post" -> "missing" to match requestUrl /posts/missing).
commit: |
createFromReadableStream() returns a React thenable whose .then() returns undefined (not a Promise). Chaining .then(normalizeAppElements) broke SSR by assigning undefined to flightRoot. Fix: call use() on the raw thenable, then normalize synchronously after resolution. Also widen renderAppPageLifecycle element type to accept flat map payloads.
The SSR entry always expects a flat Record<string, ReactNode> with __route and __rootLayout metadata from the RSC stream. Three paths were still producing bare ReactNode payloads: 1. renderAppPageBoundaryElementResponse only created the flat map for isRscRequest=true, but HTML requests also flow through RSC→SSR 2. buildPageElements "no default export" early return 3. Server action "Page not found" fallback All three now produce the flat keyed element map, fixing 17 test failures across 404/not-found, forbidden/unauthorized, error boundary, production build, rewrite, and encoded-slash paths.
- Update renderElementToStream mock to extract the route element from the flat map before rendering to HTML (mirrors real SSR entry flow) - Update entry template snapshots for the buildPageElements changes
createFromReadableStream() returns a React Flight thenable whose .then() returns undefined instead of a new Promise. The browser entry's normalizeAppElementsPromise chained .then() on this raw thenable, producing undefined — which crashed use() during hydration with "An unsupported type was passed to use(): undefined". Wrapping in Promise.resolve() first converts the Flight thenable into a real Promise, making .then() chains work correctly. The same fix was already applied to the SSR entry in 5395efc but was missed in the browser entry.
React 19.2.4's use(Promise) during hydration triggers "async Client Component" because native Promises lack React's internal .status property (set only by Flight thenables). When use() encounters a Promise without .status, it suspends — which React interprets as the component being async, causing a fatal error. Fix: store resolved AppElements directly in ElementsContext and router state instead of Promise<AppElements>. The navigation async flow (createPendingNavigationCommit) awaits the Promise before dispatching, so React state never holds a Promise. - ElementsContext: Promise<AppElements> → AppElements - AppRouterState.elements: Promise<AppElements> → AppElements - mergeElementsPromise → mergeElements (sync object spread) - Slot: useContext only, no use(Promise) - SSR entry: pass resolved elements to context - dispatchBrowserTree: simplified, no async error handler Also fix flaky instrumentation E2E test that read the last error entry instead of finding by path.
- Remove Promise wrappers from ElementsContext test values - mergeElementsPromise → mergeElements (sync) - Replace Suspense streaming test with direct render test - Remove unused createDeferred helper and Suspense import - Update browser state test assertions (no longer async)
P1a: mergeElements preserves previous slot content when the new payload marks a parallel slot as unmatched. On soft navigation, unmatched slots keep their previous subtree instead of triggering notFound(). P1b: renderNavigationPayload now receives navId and checks for superseded navigations after its await. Stale payloads are discarded instead of being dispatched into the React tree. P2: The catch block in renderNavigationPayload only calls commitClientNavigationState() when activateNavigationSnapshot() was actually reached, preventing counter underflow. P3: The no-default-export fallback in buildPageElements now derives the root layout tree path from route.layoutTreePositions and route.routeSegments instead of hardcoding "/".
|
diff should look less scary after 2a and 2b are merged and this PR is rebased |
There was a problem hiding this comment.
Pull request overview
This PR switches vinext’s App Router RSC/SSR pipeline from a single monolithic ReactNode payload to a flat keyed Record<string, ReactNode> element map, enabling layout persistence across client navigations by merging element entries instead of replacing the whole tree.
Changes:
- Introduces the flat payload contract (
__route,__rootLayout) plus normalization helpers and unmatched-slot sentinel handling. - Adds server-side route wiring builder (
buildAppPageElements) and render-dependency barriers to ensure correct serialization ordering. - Updates SSR and browser router entrypoints to consume/merge the flat map and handle hard navigations on root layout switches.
Reviewed changes
Copilot reviewed 22 out of 23 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/slot.test.ts | New tests for Slot/Children/ParallelSlot behavior, unmatched-slot normalization, and merge semantics. |
| tests/error-boundary.test.ts | Expands tests to cover pathname-based reset behavior and digest classification. |
| tests/entry-templates.test.ts | Updates route fixtures to include templateTreePositions. |
| tests/e2e/app-router/instrumentation.spec.ts | Makes assertion resilient by locating the error entry by path. |
| tests/app-router.test.ts | Adds integration check that .rsc responses include flat payload metadata/keys. |
| tests/app-render-dependency.test.ts | New tests demonstrating and validating render dependency barrier behavior. |
| tests/app-page-route-wiring.test.ts | New unit tests for flat element-map building, tree-path IDs, and template/slot wiring. |
| tests/app-page-boundary-render.test.ts | Updates boundary rendering tests to validate flat payloads for RSC fallbacks/errors. |
| tests/app-elements.test.ts | New tests for payload normalization and __route/__rootLayout invariants. |
| tests/app-browser-entry.test.ts | New tests for client reducer merge/replace, hard-nav on root layout change, and commit deferral. |
| tests/snapshots/entry-templates.test.ts.snap | Snapshot updates reflecting extracted route wiring and flat payload generation. |
| packages/vinext/src/shims/slot.tsx | Adds client Slot primitives, merge semantics, and unmatched-slot handling. |
| packages/vinext/src/shims/error-boundary.tsx | Adds pathname-based reset via ErrorBoundaryInner wrapper to match Next.js behavior. |
| packages/vinext/src/server/app-ssr-entry.ts | SSR now deserializes flat payload, validates metadata, and renders via ElementsContext + Slot. |
| packages/vinext/src/server/app-render-dependency.tsx | Adds dependency primitives used to order serialization relative to async layouts/templates. |
| packages/vinext/src/server/app-page-route-wiring.tsx | Implements buildAppPageElements producing route/layout/template/page/slot entries + wiring. |
| packages/vinext/src/server/app-page-render.ts | Broadens render lifecycle types to allow flat element maps in renderToReadableStream. |
| packages/vinext/src/server/app-page-boundary-render.ts | Boundary RSC responses now emit minimal flat payloads with route/root-layout metadata. |
| packages/vinext/src/server/app-elements.ts | Defines payload keys, wire sentinel, normalization, and metadata reader. |
| packages/vinext/src/server/app-browser-state.ts | Introduces reducer/commit helpers for merge/replace and hard-nav detection. |
| packages/vinext/src/server/app-browser-entry.ts | Reworks client runtime to manage router state via reducer and merge flat payloads. |
| packages/vinext/src/routing/app-router.ts | Tracks templateTreePositions on routes for correct template tree-path IDs. |
| packages/vinext/src/entries/app-rsc-entry.ts | Generated app entry now delegates to buildAppPageElements and emits flat payloads. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (props.pathname !== state.previousPathname && state.error) { | ||
| return { error: null, previousPathname: props.pathname }; | ||
| } | ||
| return { error: state.error, previousPathname: props.pathname }; |
There was a problem hiding this comment.
getDerivedStateFromProps always returns a new state object, even when pathname hasn’t changed and there’s no error to reset. This causes an unnecessary state update on every render and can make updates harder to reason about. Consider returning null when no state change is needed (e.g., only update when pathname !== state.previousPathname, and clear error only when that change occurs while an error is set).
| if (props.pathname !== state.previousPathname && state.error) { | |
| return { error: null, previousPathname: props.pathname }; | |
| } | |
| return { error: state.error, previousPathname: props.pathname }; | |
| if (props.pathname !== state.previousPathname) { | |
| if (state.error) { | |
| return { error: null, previousPathname: props.pathname }; | |
| } | |
| return { error: state.error, previousPathname: props.pathname }; | |
| } | |
| return null; |
There was a problem hiding this comment.
Dismissing — this is a micro-optimization with no measurable impact. The fallback path returns an object with identical values to the current state. React's class component reconciliation compares the values, so no actual re-render is triggered. Error boundaries don't re-render frequently from their own state.
Also, NotFoundBoundaryInner at line 119-128 has the exact same pattern — flagging one but not the other suggests pattern-matching rather than analysis.
| export function mergeElements(prev: AppElements, next: AppElements): AppElements { | ||
| const merged: Record<string, AppElementValue> = { ...prev, ...next }; | ||
| // On soft navigation, unmatched parallel slots preserve their previous subtree | ||
| // instead of firing notFound(). Only hard navigation (full page load) should 404. | ||
| // This matches Next.js behavior for parallel route persistence. | ||
| for (const key of Object.keys(merged)) { | ||
| if (key.startsWith("slot:") && merged[key] === UNMATCHED_SLOT && key in prev) { | ||
| merged[key] = prev[key]; | ||
| } | ||
| } | ||
| return merged; |
There was a problem hiding this comment.
mergeElements spreads prev+next and then iterates over all merged keys to handle the unmatched-slot sentinel. As the elements map grows, this makes each navigation O(totalEntries) even if only a few slot keys changed. You can keep the same semantics by iterating only over next (or only over keys in next that are UNMATCHED_SLOT and start with slot:) and restoring from prev when appropriate.
There was a problem hiding this comment.
Dismissing — the elements map has ~10-50 entries per route (one per layout, template, page, slot). This is a single cache line. The suggestion to iterate only `next` keys is technically correct but the difference is nanoseconds on a map that runs once per navigation.
| // server actions, HMR) always read the current state. The ref is updated | ||
| // synchronously during render -- not in an effect -- so there is no stale | ||
| // window between React committing a new state and the effect firing. | ||
| const stateRef = useRef(treeState); | ||
| stateRef.current = treeState; | ||
| browserRouterStateRef = stateRef; | ||
|
|
There was a problem hiding this comment.
Assigning browserRouterStateRef = stateRef during render introduces a render-phase side effect. In concurrent rendering / Strict Mode, React may start rendering a new state and later abandon it, but external callers could still observe the uncommitted browserRouterStateRef.current. Consider only wiring the module-level reference in a commit-phase effect (e.g., set the ref object once in useLayoutEffect), and update .current in a commit-phase effect as well if you need to guarantee readers only see committed state.
| // server actions, HMR) always read the current state. The ref is updated | |
| // synchronously during render -- not in an effect -- so there is no stale | |
| // window between React committing a new state and the effect firing. | |
| const stateRef = useRef(treeState); | |
| stateRef.current = treeState; | |
| browserRouterStateRef = stateRef; | |
| // server actions, HMR) always read the current state. | |
| const stateRef = useRef(treeState); | |
| // Wire the module-level browserRouterStateRef and keep stateRef.current in | |
| // sync with the committed treeState during the commit phase, not during | |
| // render, to avoid exposing uncommitted state in concurrent/Strict Mode. | |
| useLayoutEffect(() => { | |
| stateRef.current = treeState; | |
| browserRouterStateRef = stateRef; | |
| }, [treeState]); |
There was a problem hiding this comment.
Rejecting — the existing code is deliberately correct and the suggestion would introduce the exact bug the code comment (lines 332-335) warns about.
`browserRouterStateRef` is updated during render so that external callers (`navigateRsc()`, server action callbacks, HMR handler) always read the committed state immediately, with no stale window between React's commit phase and effect execution. Moving this to `useLayoutEffect` creates a timing gap where `getBrowserRouterState()` reads stale state.
The concurrent-mode concern (abandoned render leaves stale `stateRef.current`) is theoretical here: external callers only run from event handlers and effects, never during render. The next committed render corrects the value. And `browserRouterStateRef = stateRef` is a no-op after mount (same ref object).
Next.js's own router uses this same render-phase ref update pattern for the same reason.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f8e22769e2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| {children} | ||
| <ReleaseAppRenderDependency /> | ||
| </> |
There was a problem hiding this comment.
Ensure dependency barriers release on render errors
renderWithAppDependencyBarrier() only calls dependency.release() from a sibling component rendered after children. If a layout/template wrapped by this helper throws before that sibling renders, the dependency promise never resolves, and entries created with renderAfterAppDependencies() can remain suspended indefinitely (hanging or leaking an in-flight RSC response during error-boundary flows). The release path needs to run even when children errors.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Dismissing — this can't happen due to React's tree rendering model.
When a layout throws inside `renderWithAppDependencyBarrier`, React catches it at the nearest `ErrorBoundary`. The error boundary replaces the entire subtree below it with the fallback. All components that depend on this dependency (via `renderAfterAppDependencies`) are tree-descendants of the throwing layout — they never render because the error boundary already caught the error above them.
The dependency mechanism is safe precisely because the tree structure guarantees: every consumer is a descendant of the producer. An error at the producer level prevents the consumer from ever starting to render, so no component is suspended waiting for the unreleased promise.
| <Slot | ||
| id={layoutEntry.id} | ||
| parallelSlots={createAppPageParallelSlotEntries( |
There was a problem hiding this comment.
Avoid routing through missing layout slots
This Slot is always rendered for every layoutEntry, but earlier in buildAppPageElements() layouts with no default export are skipped and never written to elements[layoutEntry.id]. In that case Slot returns null, which drops layoutChildren and blanks the route subtree instead of preserving child rendering (the previous implementation simply skipped that layout wrapper). Guarding this Slot path for missing layout elements prevents silent content loss.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Review: flat keyed payload for App Router layout persistence
This is a well-executed architectural change. The flat Record<string, ReactNode> payload design cleanly separates concerns: app-elements.ts for wire format/normalization, app-browser-state.ts for reducer/disposition logic, app-render-dependency.tsx for RSC serialization ordering, and the updated app-page-route-wiring.tsx for flat-map construction. The three-state slot semantics (absent → persisted, UNMATCHED_SLOT → notFound, present → render) are correct and match Next.js parallel route persistence behavior.
I verified the items flagged by prior automated reviews:
- Redirect header ordering: Confirmed safe.
__mergeMiddlewareResponseHeaders(line 1736) runs beforeredirectHeaders.set("x-action-redirect", ...)(lines 1737-1739). The framework's.set()calls are authoritative. - basePath rejection and public file signal: Both intact.
Overall the code is solid. I have a few observations — two items I think deserve attention for this PR, and several smaller notes.
Items worth addressing
- Inline tree-path computation in generated entry could drift (comment below on
app-rsc-entry.ts:916) commitSameUrlNavigatePayloadreturnsundefinedon skip — a navigation that was silently skipped while a server action was in flight returnsundefinedto the caller with no signal that the action's UI update was dropped (comment below)
Non-blocking observations
- The render-phase ref write pattern (
stateRef.current = treeState) is well-documented and correct. The timing guarantee betweenhydrateRootanduseLayoutEffectis sound. - The
normalizeAppElementsfast path (two-pass scan) is a good optimization for the common case. pickRouteParamsreplacingmatchSourceRouteParamsis a nice simplification — using already-matched params by name instead of re-parsing URL patterns.- The
typeof document !== "undefined"guard at the module tail enables clean test/SSR imports. - Test coverage for the new primitives is thorough. The deferred-resolution test and the async-template dependency barrier test are good additions.
Summary
No blocking issues. The architecture is sound, the server/SSR/browser rendering paths are symmetric, and the test coverage is solid for Phase 1.
| // mirrors the createAppPageTreePath() logic used by buildAppPageElements(). | ||
| const _tp = route.layoutTreePositions?.[0] ?? 0; | ||
| const _segs = route.routeSegments?.slice(0, _tp) ?? []; | ||
| _noExportRootLayout = _segs.length === 0 ? "/" : "/" + _segs.join("/"); |
There was a problem hiding this comment.
This inline tree-path computation duplicates createAppPageTreePath() logic. The comment acknowledges the dependency, but the duplication is in a generated template where it's easy to miss when the canonical helper changes.
The helper is already imported by the generated entry (via app-page-route-wiring.tsx). Could this branch call createAppPageTreePath() directly instead of reimplementing it? That would eliminate the drift risk entirely:
| _noExportRootLayout = _segs.length === 0 ? "/" : "/" + _segs.join("/"); | |
| // Compute the root layout tree path for this error payload using the | |
| // canonical helper to avoid drift with buildAppPageElements(). | |
| const _tp = route.layoutTreePositions?.[0] ?? 0; | |
| _noExportRootLayout = __createAppPageTreePath(route.routeSegments, _tp); |
This would require adding createAppPageTreePath to the import from app-page-route-wiring.tsx at the top of the generated entry. If there's a reason this needs to stay inline (e.g., avoiding import weight for this error path), the current approach is fine — just flagging the risk.
| return undefined; | ||
| } | ||
|
|
||
| if (disposition === "dispatch") { |
There was a problem hiding this comment.
When disposition === "skip", commitSameUrlNavigatePayload falls through to the returnValue handling below, which returns the server action's data as if the dispatch succeeded. This is arguably correct for the caller (they get their return value regardless), but it means the UI silently doesn't update even though the action completed successfully.
Is this intentional? The navigation path (renderNavigationPayload) returns early on "skip" without proceeding further. Here, the caller can't distinguish "dispatched" from "skipped" — if the action had side effects that the UI should reflect, the user won't see them until the next navigation.
Not blocking — the same gap existed before, and the returnValue semantics are correct. But a follow-up TODO noting that server actions should ideally retry the dispatch or signal the caller when skipped would be helpful.
| @@ -548,35 +672,21 @@ function registerServerActionCallback(): void { | |||
| // If server actions ever trigger URL changes via RSC payload (instead of hard | |||
| // redirects), this would need renderNavigationPayload() + snapshotActivated=true. | |||
| if (isServerActionResult(result)) { | |||
There was a problem hiding this comment.
Subtle: both branches (isServerActionResult(result) and else) wrap the result in Promise.resolve(normalizeAppElements(...)). The normalizeAppElements call is synchronous and doesn't throw for well-formed payloads, so Promise.resolve() just wraps an already-computed value.
But if the server returns a malformed payload (e.g., missing __route), readAppElementsMetadata inside createPendingNavigationCommit will throw from an awaited microtask, which means the error surfaces as an unhandled promise rejection rather than propagating to the caller. The try/catch in registerServerActionCallback's caller would need to handle this.
This is fine for now — malformed payloads are a server bug, not a user error. Just noting the error propagation path is slightly different from the old synchronous updateBrowserTree approach.
| const layoutIndex = layoutIndicesByTreePosition.get(treePosition); | ||
| if (layoutIndex !== undefined) { | ||
| const layoutEntry = layoutEntries[layoutIndex]; | ||
| layoutDependenciesBefore[layoutIndex] = [...pageDependencies]; |
There was a problem hiding this comment.
Good: layoutDependenciesBefore[layoutIndex] = [...pageDependencies] snapshots the accumulated dependencies at this point, and pageDependencies.push(layoutDependency) adds the current layout's dependency after the snapshot. This means a layout's own entry only waits for prior layouts/templates, not itself. Clean accumulator pattern.
|
|
||
| elements[APP_ROUTE_KEY] = routeId; | ||
| elements[APP_ROOT_LAYOUT_KEY] = rootLayoutTreePath; | ||
| elements[pageId] = renderAfterAppDependencies(options.element, pageDependencies); |
There was a problem hiding this comment.
The page entry's dependency list includes all layout and template dependencies accumulated in pageDependencies. This means if any layout or template is async (e.g., fetches data), the page waits for all of them before serializing.
This is correct for the common case where layouts set request-scoped state (e.g., locale) that pages need to read. But it's worth noting that this serializes the page after all layouts/templates, not just the ones in its direct ancestor chain. For routes with many parallel slots each having their own templates, this could add unnecessary waiting.
Not an issue for Phase 1 (the map is small), but Phase 2 could refine this by tracking per-slot dependency chains separately.
|
|
||
| if (!Object.hasOwn(elements, id)) { | ||
| if (process.env.NODE_ENV !== "production" && !id.startsWith("slot:")) { | ||
| if (!warnedMissingEntryIds.has(id)) { |
There was a problem hiding this comment.
The warnedMissingEntryIds Set is module-scoped, so it persists across navigations. This means if a missing entry triggers a warning once, subsequent navigations to the same route won't warn again even if the underlying bug persists.
For dev-mode debugging, this is acceptable (first occurrence is usually enough). Just noting that if someone clears and re-navigates expecting to see the warning again, they won't.
| ): boolean { | ||
| // `null` means the payload could not identify an enclosing root layout | ||
| // boundary. Treat that as soft-navigation compatible so fallback payloads | ||
| // do not force a hard reload purely because metadata is absent. |
There was a problem hiding this comment.
Good defensive design. Treating null root layout paths as soft-navigation compatible means boundary/error payloads (which may not have full layout metadata) don't force unnecessary hard reloads. This is the right default — hard navigation should only trigger when we have positive evidence of a layout boundary change, not when metadata is absent.
| flightRoot = createFromReadableStream<AppWireElements>(ssrStream); | ||
| } | ||
| return flightRoot as unknown as ReactNode; | ||
| const wireElements = use(flightRoot); |
There was a problem hiding this comment.
Nice: the SSR path follows the identical deserialization pipeline as the browser entry — createFromReadableStream<AppWireElements> → normalizeAppElements → readAppElementsMetadata → ElementsContext.Provider + Slot. This symmetry is important for hydration mismatch avoidance.
|
Review posted successfully. Here's a summary of what I submitted: Review for PR #750 (flat keyed payload for App Router layout persistence): Overall assessment: No blocking issues. The architecture is sound — clean separation of concerns across Verified prior review concerns: Redirect header ordering is safe (merge before 8 inline comments posted:
|
|
/bigbonk review |
There was a problem hiding this comment.
Review: flat keyed payload for App Router layout persistence
This is a well-designed architectural change. The flat Record<string, ReactNode> payload with merge/replace semantics is a clean foundation for layout persistence. The separation of concerns across the new modules (app-elements.ts, app-browser-state.ts, app-render-dependency.tsx) is solid, and the server/SSR/browser deserialization pipeline is symmetric — both paths go through normalizeAppElements → readAppElementsMetadata → ElementsContext.Provider + Slot.
I verified the prior automated review concerns:
- Redirect header ordering: Confirmed safe at lines 1736-1739 in
app-rsc-entry.ts—__mergeMiddlewareResponseHeadersruns beforeredirectHeaders.set(). - basePath rejection and public file signal: Both intact.
The test coverage is thorough for the new state primitives, normalization, dependency barriers, and flat-map construction. The integration test at app-router.test.ts:131 verifying the wire format contract is a good addition.
I have two items worth attention (one should be addressed in this PR, one is a Phase 2 note), plus a few minor observations.
Summary
No blocking issues. The architecture is sound, the three rendering paths are properly symmetric, and the test coverage is solid.
| false, | ||
| ); | ||
| return result; | ||
| return commitSameUrlNavigatePayload(Promise.resolve(normalizeAppElements(result))); |
There was a problem hiding this comment.
Stale comment block (lines 670-677) references removed symbols.
The comment mentions updateBrowserTree, snapshotActivated, and handleAsyncError — none of which exist in the new code. The function now delegates to commitSameUrlNavigatePayload, which handles disposition classification internally. The comment should be updated to describe the current flow.
| return commitSameUrlNavigatePayload(Promise.resolve(normalizeAppElements(result))); | |
| // Server actions stay on the same URL and use commitSameUrlNavigatePayload() | |
| // for merge-based dispatch. This path does NOT call activateNavigationSnapshot() | |
| // because there is no URL change to commit — hooks use useSyncExternalStore | |
| // values directly. If server actions ever trigger URL changes via RSC payload | |
| // (instead of hard redirects), this would need renderNavigationPayload(). |
| action: { | ||
| elements, | ||
| navigationSnapshot: options.navigationSnapshot, | ||
| renderId: options.renderId ?? options.currentState.renderId + 1, |
There was a problem hiding this comment.
Minor: when renderId is not provided, the fallback currentState.renderId + 1 could produce duplicate renderIds if called multiple times without updating state between calls (e.g., two rapid server actions both reading the same currentState). In practice this doesn't happen because commitSameUrlNavigatePayload increments nextNavigationRenderId and passes it explicitly. But the fallback suggests renderId is truly optional when it's effectively required for correctness.
Consider making renderId required in the type signature to make this invariant explicit — it's always passed by callers.
| return undefined; | ||
| } | ||
|
|
||
| if (disposition === "dispatch") { |
There was a problem hiding this comment.
When disposition === "skip", execution falls through to the returnValue handling below. This means the caller gets their return value even though the UI update was silently dropped. The navigation path (renderNavigationPayload at line 498) returns early on "skip" without proceeding.
This asymmetry is probably intentional — server action return values shouldn't be lost just because a navigation superseded the UI update. But it's worth a one-line comment here making it explicit that the fall-through is deliberate:
| if (disposition === "dispatch") { | |
| if (disposition === "dispatch") { | |
| dispatchBrowserTree( |
Actually the fall-through is fine as-is since the comment at line 358-361 already explains this. Disregard — the existing comment covers this well.
| "replace", | ||
| pending.routeId, | ||
| pending.rootLayoutTreePath, | ||
| false, |
There was a problem hiding this comment.
The HMR path uses createPendingNavigationCommit but doesn't call resolvePendingNavigationCommitDisposition — it always does a full replace dispatch. If a route group restructuring during HMR changes rootLayoutTreePath, this would update state without triggering a hard navigation. The next soft navigation could then compare against a stale rootLayoutTreePath.
Not blocking — dev-only, and the author acknowledged this in a prior review. Just confirming this is a known gap.
| if (cached) { | ||
| return cached; | ||
| export function mergeElements(prev: AppElements, next: AppElements): AppElements { | ||
| const merged: Record<string, AppElementValue> = { ...prev, ...next }; |
There was a problem hiding this comment.
Design note: mergeElements creates a new object on every soft navigation (spread + iteration). For the current map sizes (~10-50 entries) this is fine. If Phase 2+ entry eviction significantly grows the map, consider tracking whether any key actually changed and short-circuiting — but that's premature optimization at this point.
| // owning entry has reached the serialization point that downstream entries | ||
| // are allowed to observe. If Phase 2 adds AbortSignal-based render | ||
| // timeouts, this dependency will also need an abort/reject path so stuck | ||
| // async layouts do not suspend downstream entries forever. |
There was a problem hiding this comment.
Good comment about the Phase 2 AbortSignal-based timeout. One additional note: release() is idempotent (the released guard), but there's no reject() path. When the AbortSignal work lands, AwaitAppRenderDependencies will need to handle promise rejection from aborted dependencies — the current Promise.all(dependencies.map(d => d.promise)) would need to be wrapped in error handling or the dependency promise would need a rejection path that the AwaitAppRenderDependencies component can catch and propagate as an error boundary trigger.
|
|
||
| elements[APP_ROUTE_KEY] = routeId; | ||
| elements[APP_ROOT_LAYOUT_KEY] = rootLayoutTreePath; | ||
| elements[pageId] = renderAfterAppDependencies(options.element, pageDependencies); |
There was a problem hiding this comment.
The page entry's dependency list includes all accumulated dependencies from every layout and template, not just direct ancestors. For routes with parallel slots that each have their own templates (e.g., a dashboard with @analytics and @team slots, each with templates), the page would wait for all slot templates even though it doesn't depend on their state.
This is correct for Phase 1 where the map is small and the pattern matches Next.js behavior (layouts set request-scoped context that pages need). But it's worth noting as a Phase 2 optimization opportunity — per-slot dependency chains could reduce unnecessary waiting for deeply parallel route structures.
| } | ||
| } | ||
|
|
||
| return layoutModules.length > 0 ? "/" : null; |
There was a problem hiding this comment.
Good defensive fallback. When route.layouts is empty/absent (e.g., a global error boundary response), returning null for rootLayoutTreePath ensures the client treats it as soft-navigation compatible rather than forcing a hard reload. This aligns with shouldHardNavigate treating null as compatible.
| } | ||
|
|
||
| const rootLayoutTreePath = elements[APP_ROOT_LAYOUT_KEY]; | ||
| if (rootLayoutTreePath === undefined) { |
There was a problem hiding this comment.
Good: the three-way error reporting now distinguishes:
- Missing
__route(line 44-46) - Missing
__rootLayoutkey (line 49-51) - Invalid
__rootLayouttype (line 52-53)
This addresses the feedback from earlier reviews about the misleading error messages.
| rootNotFoundModule: null, | ||
| }); | ||
|
|
||
| const body = await renderHtml( |
There was a problem hiding this comment.
This test renders the elements as siblings in a Fragment rather than through the ElementsContext + Slot rendering path that the actual runtime uses. The test at line 259 (renderRouteEntry) does exercise the real Slot path.
For this specific test (template-only segments with async dependency barriers), the Fragment approach is fine because it directly verifies the serialization ordering guarantee — if page:de appears in the output, the dependency barrier worked regardless of rendering path. Just noting the distinction for future test authors.
|
Review posted successfully. Here's a summary of what I submitted: Review posted on PR #750Overall assessment: No blocking issues. The architecture is sound. 10 inline comments covering:
|
|
/bigbonk review |
There was a problem hiding this comment.
Review: flat keyed payload for App Router layout persistence
This is a well-designed architectural shift. I read through every changed source file and test. The core design — flat Record<string, ReactNode> with wire/runtime sentinel separation, merge semantics on soft navigation, replace on HMR, render dependency DAG for serialization ordering — is clean and correct.
Verified prior review concerns
- Redirect header ordering: Confirmed safe. Lines 1737-1740 in
app-rsc-entry.tsshow__mergeMiddlewareResponseHeadersruns first, thenredirectHeaders.set("x-action-redirect", ...)runs after. Framework values are authoritative. - basePath rejection: Intact at lines 1261-1269.
- Public file static signal: Intact.
Architecture observations
The good:
-
Clean module decomposition. The extraction into
app-elements.ts(wire format + normalization),app-browser-state.ts(reducer + disposition classification),app-render-dependency.tsx(serialization DAG), and the updatedapp-page-route-wiring.tsx(flat-map builder) is solid separation of concerns. Each module is independently testable. -
Server/SSR/Browser symmetry. All three paths follow the identical deserialization pipeline:
createFromReadableStream<AppWireElements>→normalizeAppElements→readAppElementsMetadata→ElementsContext.Provider+Slot. This eliminates hydration mismatch risk. -
pickRouteParamsreplacingmatchSourceRouteParams. Using already-matched params by name instead of re-parsing URL patterns is simpler and more correct. The old approach had edge cases with pattern matching against request pathnames. -
Template discovery decoupling.
discoverTemplatesindependently discovers all templates and usestemplateTreePositionsfor alignment, correctly handling templates without layouts at the same directory level. The olddiscoverLayoutAlignedTemplateswould miss these. -
normalizeAppElementsfast path. Two-pass scan avoids allocation when no normalization is needed — good hot-path optimization for the common case. -
Three-state slot semantics. Absent key → persisted from previous soft nav,
UNMATCHED_SLOT→ notFound(), present null → valid render from default.tsx. This correctly models Next.js parallel route persistence behavior. -
Test coverage. Thorough for all new primitives. The render dependency barrier test (
app-render-dependency.test.ts) demonstrating the race condition without barriers is particularly good pedagogy.
Items for consideration
I have no blocking issues. Below are observations that could improve clarity or inform Phase 2 work.
See inline comments for details.
| false, | ||
| ); | ||
| return result; | ||
| return commitSameUrlNavigatePayload(Promise.resolve(normalizeAppElements(result))); |
There was a problem hiding this comment.
The comment block (lines 670-677) describes the current server action flow accurately. One thing I want to flag: this is one of two places in this file that creates a commitSameUrlNavigatePayload call — the other is line 677. The isServerActionResult branch extracts result.root, while the else branch passes result directly. Both normalize and wrap in Promise.resolve().
This is correct, but the near-identical structure across both branches is an invitation for a future fix to miss one. The author and ask-bonk already discussed this — deferring extraction to Phase 2 is fine. Just confirming I agree with the decision.
| currentState: AppRouterState; | ||
| nextElements: Promise<AppElements>; | ||
| navigationSnapshot: ClientNavigationRenderSnapshot; | ||
| renderId: number; |
There was a problem hiding this comment.
Minor type design point: renderId in createPendingNavigationCommit's options is required (good — prevents accidental duplicate IDs), and the test at app-browser-entry.test.ts:44 explicitly verifies this with @ts-expect-error. The routerReducer action type also requires renderId. This is consistent.
However, resolveAndClassifyNavigationCommit at line 114 also takes renderId — and its value flows through to createPendingNavigationCommit. This creates two places where callers must supply renderIds. Currently only commitSameUrlNavigatePayload calls resolveAndClassifyNavigationCommit, so there's no risk of confusion. But if Phase 2 adds more callers, consider whether the renderId should be generated inside the helper rather than passed in.
| const routeThenableParams = options.makeThenableParams(options.matchedParams); | ||
| const getEffectiveSlotParams = (slotName: string): Record<string, string | string[]> => | ||
| options.slotOverrides?.[slotName]?.params ?? options.matchedParams; | ||
| const rootLayoutTreePath = layoutEntries[0]?.treePath ?? null; |
There was a problem hiding this comment.
Good design: rootLayoutTreePath correctly includes route group segments because createAppPageTreePath slices routeSegments (which include groups like (marketing)) up to the layout's tree position. This means navigating between /(marketing)/... and /(dashboard)/... produces different tree paths, triggering shouldHardNavigate() in the browser state. This is the correct behavior.
| const layoutIndex = layoutIndicesByTreePosition.get(treePosition); | ||
| if (layoutIndex !== undefined) { | ||
| const layoutEntry = layoutEntries[layoutIndex]; | ||
| layoutDependenciesBefore[layoutIndex] = [...pageDependencies]; |
There was a problem hiding this comment.
Clean accumulator pattern. layoutDependenciesBefore[layoutIndex] snapshots pageDependencies before adding the current layout's dependency, then pageDependencies.push(layoutDependency) adds it after. This ensures each layout waits for prior layouts/templates but not itself. The same approach at line 374 for templates is correct.
One thing to note for Phase 2: pageDependencies accumulates all layout and template dependencies linearly. For routes with deeply parallel slot structures, the page ends up waiting for slots' templates that it doesn't directly depend on. Per-slot dependency chains could reduce unnecessary serialization blocking.
| // Build the segment map for this layout level. The "children" key always | ||
| // contains the route segments below this layout. Named parallel slots at | ||
| // this layout level add their own keys with per-slot segment data. | ||
| if (!layoutEntry) { |
There was a problem hiding this comment.
Correct: when layoutEntry is absent but templateEntry is present (template-only segment), the code at line 559 wraps segmentChildren in the template Slot, then line 575 assigns routeChildren = segmentChildren and continues. The layout boundaries (NotFoundBoundary, ErrorBoundary at lines 540-556) are gated on if (layoutEntry) so they're correctly skipped for template-only positions.
This matches Next.js behavior where template.tsx without layout.tsx still re-mounts children on navigation.
| return children; | ||
| } | ||
|
|
||
| function normalizeAppElementsPromise(payload: Promise<AppWireElements>): Promise<AppElements> { |
There was a problem hiding this comment.
Good documentation on the React Flight thenable quirk. createFromReadableStream() returns a Flight thenable whose .then() returns undefined (not a new Promise), so chaining without Promise.resolve() wrapping would produce undefined → use() crashes.
This is subtle enough that I'd suggest keeping the comment even if it seems redundant — future developers who see Promise.resolve(payload).then(...) and think "the Promise.resolve is unnecessary" would remove it and introduce a crash.
| let _noExportRootLayout = null; | ||
| if (route.layouts?.length > 0) { | ||
| // Compute the root layout tree path for this error payload using the | ||
| // canonical helper so it stays aligned with buildAppPageElements(). |
There was a problem hiding this comment.
Good: the no-default-export path now calls __createAppPageTreePath(route.routeSegments, _tp) instead of reimplementing the tree-path computation inline. This addresses the earlier review concern about drift risk between the canonical helper and inline logic. The comment at lines 916-918 documents the intent clearly.
| } | ||
|
|
||
| void main(); | ||
| if (typeof document !== "undefined") { |
There was a problem hiding this comment.
Clean guard. typeof document !== 'undefined' prevents main() from executing in test/SSR contexts. This is the correct pattern for a module that's imported by the test suite but should only auto-execute in a real browser.
| } | ||
|
|
||
| describe("app render dependency helpers", () => { | ||
| it("documents that React can render a sync sibling before an async sibling completes", async () => { |
There was a problem hiding this comment.
Excellent test design. The first test ("documents that React can render a sync sibling before an async sibling completes") demonstrates the problem — React renders LocalePage (sync) before LocaleLayout (async) has set activeLocale = "de", so the page sees "en". The second test shows that renderWithAppDependencyBarrier + renderAfterAppDependencies fixes this by deferring the page until the layout has rendered.
This is great pedagogy — anyone reading these tests immediately understands why the render dependency mechanism exists.
|
|
||
| it("resolves slot segmentMap from matched params when no override exists", () => { | ||
| const element = buildAppPageRouteElement({ | ||
| it("renders same-named slot props independently at different layout levels", async () => { |
There was a problem hiding this comment.
Good test for the slot collision fixture. This verifies that same-named slots (@modal) at different layout levels (/parent/@modal and /parent/child/@modal) are keyed by their stable slot id (including the owner path), not just the prop name. The slotKey distinction (modal@parent/@modal vs modal@parent/child/@modal) is what makes this work.
The companion test at line 365 ("does not apply ambiguous name-only slot overrides") correctly verifies that intercept overrides keyed only by "modal" are rejected when multiple same-named slots exist.
|
Review posted successfully on PR #750. Here's a summary: Overall assessment: No blocking issues. The architecture is sound. The PR switches from a monolithic
I verified the prior automated review concerns (redirect header ordering, basePath rejection, public file signal) are all non-issues. Posted 14 inline comments covering architecture observations, correctness confirmations, and Phase 2 considerations on the key design points. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 31 out of 31 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| function resolveAppPageBoundaryRootLayoutTreePath<TModule extends AppPageModule>( | ||
| route: AppPageBoundaryRoute<TModule> | null | undefined, | ||
| layoutModules: readonly (TModule | null | undefined)[], | ||
| ): string | null { | ||
| if (route?.layouts) { | ||
| const rootLayoutEntry = createAppPageLayoutEntries({ | ||
| errors: route.errors, | ||
| layoutTreePositions: route.layoutTreePositions, | ||
| layouts: route.layouts, | ||
| notFounds: null, | ||
| routeSegments: route.routeSegments, | ||
| })[0]; | ||
|
|
||
| if (rootLayoutEntry) { | ||
| return rootLayoutEntry.treePath; | ||
| } | ||
| } | ||
|
|
||
| return layoutModules.length > 0 ? "/" : null; | ||
| } |
There was a problem hiding this comment.
resolveAppPageBoundaryRootLayoutTreePath falls back to returning "/" when route is missing but layoutModules is non-empty. In apps with multiple root layouts under route groups, this can mis-identify the active root layout and incorrectly trigger a client hard navigation (since the browser compares rootLayoutTreePath values). Consider returning null (soft-navigation compatible per shouldHardNavigate) when the tree path cannot be derived from route.routeSegments/layoutTreePositions, or otherwise computing the tree path from available route metadata if present.
| /** Ordered list of layout files from root to leaf */ | ||
| layouts: string[]; | ||
| /** Template files aligned with layouts array (null where no template exists at that level) */ | ||
| templates: (string | null)[]; | ||
| /** Ordered list of template files from root to leaf (parallel to layouts) */ | ||
| templates: string[]; | ||
| /** Parallel route slots (from @slot directories at the route's directory level) */ |
There was a problem hiding this comment.
The templates field is documented as "parallel to layouts", but discoverTemplates() now collects templates independently of layouts (templates can exist at levels without a layout). This comment is misleading and makes it harder to reason about templateTreePositions/template wiring. Update the docstring to describe templates as an ordered list of all discovered templates from root to leaf, not necessarily aligned 1:1 with layouts.
… path Address Copilot review comments on PR cloudflare#750: 1. resolveAppPageBoundaryRootLayoutTreePath now returns null (instead of "/") when the route is absent, preventing incorrect hard navigations in apps with multiple root layouts under route groups. The null value is already handled by shouldHardNavigate as soft-navigation compatible. Removed the now-unused layoutModules parameter. 2. Update AppRoute.templates docstring to clarify that templates are discovered independently and not necessarily aligned 1:1 with layouts. https://claude.ai/code/session_01VYXRX9QjFh292eSUSeJ5s1
a57a67d to
cd0fcbf
Compare
|
/bigbonk review |
There was a problem hiding this comment.
Review: flat keyed payload for App Router layout persistence
This is a well-designed architectural change. The flat Record<string, ReactNode> payload with merge/replace semantics is a clean foundation for layout persistence. The module decomposition is solid — app-elements.ts, app-browser-state.ts, app-render-dependency.tsx, and the updated app-page-route-wiring.tsx are independently testable and well-separated. Server/SSR/browser deserialization symmetry (normalizeAppElements → readAppElementsMetadata → ElementsContext.Provider + Slot) eliminates hydration mismatch risk.
I verified the prior review concerns:
- Redirect header ordering: Confirmed safe —
__mergeMiddlewareResponseHeadersruns beforeredirectHeaders.set(). - basePath rejection and public file signal: Both intact.
Test coverage is thorough for all the new primitives. The render dependency barrier test demonstrating the race without barriers is excellent pedagogy.
Findings
No blocking issues. Two minor stale-comment items (with suggestions), plus non-blocking design observations. See inline comments.
Stale references
app-browser-entry.ts:132— Comment references removedupdateBrowserTree. Not in the diff so I can't suggest inline, but should be updated tocommitSameUrlNavigatePayload.tests/app-router.test.ts:498,539— Comments reference removedmatchSourceRouteParams, should bepickRouteParams. Also not in the diff.
Positive observations
normalizeAppElementsfast path — Two-pass scan avoids allocation when no normalization needed. Good hot-path optimization.pickRouteParamsreplacingmatchSourceRouteParams— Simpler and more correct; uses already-matched params by name instead of re-parsing URL patterns.typeof documentguard at module tail enables clean test/SSR imports.- Render dependency DAG —
orderedTreePositions+ accumulator pattern elegantly ensures correct serialization ordering. - Reducer exhaustive switch with
neverdefault branch — good TypeScript pattern. stageClientParamsmoved into commit effect — Eliminates the race where params were staged before confirming the navigation wasn't superseded.
| return Promise.resolve(payload).then((elements) => normalizeAppElements(elements)); | ||
| } | ||
|
|
||
| async function commitSameUrlNavigatePayload( |
There was a problem hiding this comment.
Design observation: commitSameUrlNavigatePayload always uses type: "navigate" (merge semantics) at line 333, but the function name doesn't communicate this merge-only contract. renderNavigationPayload accepts actionType as a parameter, which could mislead someone into thinking this function should too.
A brief doc comment like /** Same-URL payloads always merge (type: "navigate"), never replace. */ would make the contract explicit for anyone reading this without the PR review context. Not blocking.
| // unincremented counter would corrupt it for concurrent RSC navigations. | ||
| // If server actions ever trigger URL changes via RSC payload (instead of hard | ||
| // redirects), this would need renderNavigationPayload() + snapshotActivated=true. | ||
| // Server actions stay on the same URL and use commitSameUrlNavigatePayload() |
There was a problem hiding this comment.
Good rewrite of this comment block. The new text accurately describes the current commitSameUrlNavigatePayload() flow and correctly explains why activateNavigationSnapshot() is not called.
One detail: both the isServerActionResult(result) branch (line 677) and the else branch (line 683) create near-identical Promise.resolve(normalizeAppElements(...)) → commitSameUrlNavigatePayload(...) calls. The only structural difference is extracting .root vs using the result directly. This is the duplication ask-bonk flagged — I agree deferring extraction to Phase 2 is the right call, but worth a one-line comment noting it:
// Both branches below delegate to commitSameUrlNavigatePayload(); the
// only difference is unwrapping .root for ServerActionResult payloads.
| rscPayload, | ||
| createClientNavigationRenderSnapshot(window.location.href, latestClientParams), | ||
| ++nextNavigationRenderId, | ||
| const pending = await createPendingNavigationCommit({ |
There was a problem hiding this comment.
The HMR path uses createPendingNavigationCommit but doesn't check disposition — it always does a full replace. If an HMR update changes rootLayoutTreePath (route group restructuring), the state silently updates without triggering a hard navigation. The next soft navigation's shouldHardNavigate() check would then compare against the HMR-replaced path.
The author acknowledged this is a known dev-only gap. Confirming agreement — fine for PR 2c.
| if (!pagePath && !defaultPath && interceptingRoutes.length === 0) continue; | ||
|
|
||
| slots.push({ | ||
| key: `${slotName}@${path.relative(appDir, slotDir).replace(/\\/g, "/")}`, |
There was a problem hiding this comment.
The slot key format modal@parent/@modal embeds the @ prefix of the slot directory into the path portion, making it look like there's a second slot name. It works correctly, but when reading code at the serialization sites (app-page-route-wiring.tsx:264, app-rsc-entry.ts slot entries) and the override lookup (resolveSlotOverride), the key structure isn't immediately obvious.
Consider adding a brief doc comment on the key field in the ParallelSlot type (line 40) with the format: // Format: "{slotName}@{relativePath}", e.g. "modal@parent/@modal". Not blocking.
| // On soft navigation, unmatched parallel slots preserve their previous subtree | ||
| // instead of firing notFound(). Only hard navigation (full page load) should 404. | ||
| // This matches Next.js behavior for parallel route persistence. | ||
| for (const key of Object.keys(merged)) { |
There was a problem hiding this comment.
Minor: this iterates all keys of merged (union of prev and next), but the UNMATCHED_SLOT check is only relevant for keys in next. For prev-only keys, merged[key] is always the prev value (never UNMATCHED_SLOT), so the condition is dead for those entries.
Functionally correct and the map is small (~10-50 entries). Just noting for anyone optimizing this later.
| getDefaultExport(slot.default); | ||
|
|
||
| if (!slotComponent) { | ||
| elements[slotId] = APP_UNMATCHED_SLOT_WIRE_VALUE; |
There was a problem hiding this comment.
When slotComponent is null (no page, no default), the slot is marked as APP_UNMATCHED_SLOT_WIRE_VALUE. This correctly produces the three-state behavior.
One subtlety worth noting: getDefaultExport(slot.default) returns null when slot.default is { default: null }. So a default.tsx that exports null as its default export would hit the UNMATCHED_SLOT path (404 on hard nav, preserve on soft nav). This matches Next.js behavior where a null default export is treated as "no component", but it's slightly surprising since the file's existence is typically what prevents 404. Worth a brief inline comment if someone asks.
| pending: PendingNavigationCommit; | ||
| }; | ||
|
|
||
| export function routerReducer(state: AppRouterState, action: AppRouterAction): AppRouterState { |
There was a problem hiding this comment.
Good: the exhaustive switch with never default ensures new action types get compile-time enforcement. The "navigate" → merge and "replace" → overwrite semantics are clear and correct.
One observation: mergeElements inside the "navigate" case creates a new object on every soft navigation. For current map sizes this is fine, but if Phase 2+ adds many more entries, the shallow copy + iteration could become a consideration. Not an issue now.
| // are allowed to observe. If Phase 2 adds AbortSignal-based render | ||
| // timeouts, this dependency will also need an abort/reject path so stuck | ||
| // async layouts do not suspend downstream entries forever. | ||
| dependency.release(); |
There was a problem hiding this comment.
Good forward-looking comment about Phase 2 AbortSignal timeouts. To reinforce: when that lands, AwaitAppRenderDependencies (line 37) uses Promise.all(...) which would reject immediately on a single aborted dependency, but there's currently no rejection path on the dependency promise. The release() is idempotent (the released guard) but there's no reject() — so Phase 2 would need to add dependency.abort(reason) and have the AwaitAppRenderDependencies component catch and propagate the rejection.
|
The review was posted successfully. Here's a summary of what I submitted: Review posted on PR #750Overall assessment: No blocking issues. The architecture is sound. Top-level review with 8 inline comments covering:
Stale references called out in the top-level summary (couldn't be added inline since they're in unchanged code):
|
|
@james-elicx is bonk not allowed to approve big architectural changes? |
|
It is |
|
I'm not sure why this hasn't been approved... last 2 reviews were just about comment nits and observations |
|
I can run through the diff but it might take a while due to the size |
|
/bigbonk re-review for issues |
There was a problem hiding this comment.
Review: flat keyed payload for App Router layout persistence
This is a well-executed architectural change. I read through every modified source file, the new modules, and the test suite. The design is sound and the implementation is careful.
Architecture assessment
The core idea — replacing a monolithic ReactNode with a flat Record<string, ReactNode> keyed by tree path — is the right foundation for layout persistence. The key design decisions are all correct:
- Wire/runtime sentinel separation (
APP_UNMATCHED_SLOT_WIRE_VALUEstring →UNMATCHED_SLOTSymbol) avoids accidental comparisons in user code while keeping the wire format JSON-safe. - Three-state slot semantics (absent = persisted, Symbol = notFound, present = render) correctly model Next.js parallel route persistence.
- Render dependency DAG ensures async layouts complete before their dependent entries serialize — the
renderWithAppDependencyBarrier+renderAfterAppDependenciespattern is clean. useReducer-based state with merge/replace actions and an exhaustive switch is a good fit for the two distinct update modes.- Server/SSR/Browser deserialization symmetry — all three paths follow
normalizeAppElements → readAppElementsMetadata → ElementsContext.Provider + Slot. This is the right way to guarantee hydration match.
The pickRouteParams replacement for matchSourceRouteParams is a genuine improvement — using already-matched params by name instead of re-parsing URL patterns eliminates an entire class of edge cases.
Verified prior concerns
- Redirect header ordering: Confirmed safe.
__mergeMiddlewareResponseHeadersat line 1737 runs beforeredirectHeaders.set()at lines 1738-1740. The removed comment was redundant; the behavior is now enforced by the test atentry-templates.test.ts:236-246. - basePath rejection and public file signal: Both intact.
- Render-phase ref write: The author's dismissal of Copilot's suggestion is correct. External readers only run from events/effects, not during render.
Items noted (non-blocking)
See inline comments for details on three specific observations. None are blocking — the PR is ready to merge.
Test coverage
Thorough. The new test files (app-browser-entry.test.ts, app-elements.test.ts, app-render-dependency.test.ts) cover the new primitives well. The render dependency test that demonstrates the race condition without barriers is excellent — it makes the motivation for the mechanism immediately clear to anyone reading the tests.
The slot collision fixture (app/slot-collision/) and the corresponding routing test verify the slotKey uniqueness guarantee for same-named slots at different tree positions.
| } | ||
| // Don't cache the response if this navigation was superseded during | ||
| // renderNavigationPayload's await — the elements were never dispatched. | ||
| if (navId !== activeNavigationId) return; |
There was a problem hiding this comment.
Good addition. Without this guard, a superseded navigation would cache a response whose elements were never dispatched — a back/forward to that URL would replay the stale snapshot. The navId !== activeNavigationId check here correctly prevents that.
This also means the visited response cache only contains entries from navigations that actually rendered, which is the right invariant.
| currentState, | ||
| navigationSnapshot, | ||
| nextElements, | ||
| renderId: ++nextNavigationRenderId, |
There was a problem hiding this comment.
Subtle: ++nextNavigationRenderId is evaluated eagerly (before await), so even if two commitSameUrlNavigatePayload calls race, they get distinct render IDs. This is correct. However, the startedNavigationId captured at line 326 is the same activeNavigationId that was read before the await — if another navigation completes during the await and dispatches newer elements, this action's startedNavigationId still matches activeNavigationId (since navigations don't reset activeNavigationId on completion, only on start). The TODO at line 337-341 covers this. Just confirming the analysis is accurate.
| const cached = nextCache.get(next); | ||
| if (cached) { | ||
| return cached; | ||
| export function mergeElements(prev: AppElements, next: AppElements): AppElements { |
There was a problem hiding this comment.
Minor note: mergeElements is a pure function with no React dependencies — it only uses UNMATCHED_SLOT (a Symbol) and Object.hasOwn. It currently lives in a "use client" module, which means app-browser-state.ts (a non-directive module under server/) imports from a client-directive module.
This works correctly because the browser entry bundles everything together. But if mergeElements is ever needed in a server-only context (e.g., server-side element caching in Phase 2+), the "use client" directive would cause issues. Consider extracting mergeElements to app-elements.ts in a future cleanup — it's a better conceptual home for a pure element-map utility. Not blocking.
|
Review submitted successfully with approval. Here's a summary of what I reviewed and posted: Approved PR #750 — flat keyed payload for App Router layout persistence. What I verified:
3 inline comments posted (all non-blocking):
|
Summary
Switches the App Router from a monolithic
ReactNodepayload to a flat keyedRecord<string, ReactNode>element map (#726, Phase 1 PR 2c). Layouts now persist across navigations — the server emits separate entries for each layout, template, page, and parallel slot, and the browser merges new entries into the existing map instead of replacing the entire tree.This is the core cutover. Includes PRs 2a (client primitives) and 2b (route wiring extraction) as base commits.
What changed
buildAppPageElements()replaces the inline monolithic tree builder. Produces flat entries keyed by tree path (includes route groups), plus__routeand__rootLayoutmetadata. All RSC render paths switched: normal routes, ISR, error/not-found/forbidden fallbacks, server actionsuseReducer-based router state with atomicelements+routeId+rootLayoutTreePath. Navigation/refresh/server actions/back-forward use merge semantics. HMR uses replace. Root layout switches trigger hard navigation (window.location.assign). URL state deferred until payload resolves (torn URL fix)VinextFlightRootreads flat payload, validates__route, renders throughElementsContext+Slot. GlobalErrorBoundary, ServerInsertedHTMLContext, font injection preserved at same positions"__VINEXT_UNMATCHED_SLOT__"normalized toUNMATCHED_SLOTSymbol after deserialization. Three states: absent key = persisted from soft nav, Symbol = unmatched (notFound), present null = valid render from default.tsxWhat this does NOT do
Skip-header optimization, entry eviction/LRU, interception-context encoding, incremental per-entry streaming. Those are Phases 2-4.
Test plan
tests/slot.test.ts— wire marker normalization, three-state distinctiontests/app-elements.test.ts— normalize,__routeinvariant,__rootLayoutvalidationtests/app-browser-entry.test.ts— reducer merge/replace, root-layout hard nav, torn URL, refresh/back-forward/server-action mergetests/app-page-route-wiring.test.ts— flat-map entries, tree-path IDs, per-slot segment providers, template keystests/error-boundary.test.ts— pathname reset preservedtests/entry-templates.test.ts— snapshots updated for flat payloadtests/app-router.test.ts— integration test for flat payload contractvp check— 0 lint/type errors